就 Redux 而言,與上一篇的 Counter
不同的地方就是多了 Action 以及 Reducer,而它們也都只是純函數,測試並不會是難點,需要思考的地方主要是需模擬出 Redux 的 Store 讓 Component 不會因為沒有 Provider
提供資料而在 Render 時出錯。
與測試 Counter
相同,第一步先到__tests__/component 中建立對應的 Component 測試檔案:
|-__tests__
|-component
|-Content
|-Content.test.jsx
|-Counter
撰寫測試案例之前,都要思考該寫些什麼才符合受測 Component,大家可以先以上一章學會的方式,思考一下 Content
這個 Component 擁有哪些行為,並列出要寫下的測試案例,再繼續往下確認有沒有相同。
根據行為,要為 Content
撰寫的測試案例會有兩個:
Content
有正常 Render 畫面。下一步就能依照上方兩點寫下測試案例,但別忘了要先在 Content
的 DOM 加上 data-testid
,要設置的分別是最外層的 div
,會用它來判斷 Content
有沒有正常 Render,另外是顯示資料的 div
及請求資料的 button
:
接下來寫下驗證 Content
是否正常 Render 的測試:
輸入指令 npm run test
確認是否通過測試,但顯然出了一點問題:
錯誤內容的重點如下:
regeneratorRuntime is not defined
這裡又是考驗記憶力的時間了,在前幾個章節我們使用 Redux Saga 時,也有遇過類似的問題,而當初我們下載了 @babel/polyfill,並在 webpack.config.js 中的進入點設置預先載入,解決有些較新語法還不支援的問題。
現在我們也遇見了,但是在測試中不會經過 @babel/polyfill 預先載入那些語法,因此這裡另外下載一個 Babel 的 Pugin,在測試時代替 @babel/polyfill 做這件事情:
npm install --save-dev @babel/plugin-transform-runtime
下載完後,打開 .babelrc.js 在 plugins
中加上它:
完成後再執行一次測試,仍然會出現錯誤,但這次的錯誤訊息有點長,筆者就擷取訊息中的精華部分:
關鍵字就是:
Invariant Violation: could not find react-redux context value; please ensure the component is wrapped in a
這個也就是前言裡提到的,測試擁有 Redux 的 Component,該怎麼模擬出 Store 來,要解決這個過程也很簡單,第一步先將 createStore
、Provider
以及創建 createStore
使用到的 Reducer import
進測試檔案中:
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import reducer from '../../../src/reducer/todolist';
有了 createStore
以及 reducer
後,便能直接創建 store 了:
const store = createStore(reducer);
這裡特別提一下 Reducer 的初始 State,一般來說,如果不另外指定的話,就會是使用當初在 Reducer 內寫好的 State 初始化給 Store,在測試內也是這樣子,但是有時候專案會需要建立特別的測試環境,要另外指定一開始的 State 時,也可以這麼做:
const store = createStore(
reducer, { /*另外設置 State */ }
);
有了測試時建立的 store 後,便能在 Render 時將 Provider
放到受測 Component 的外層,並將 store
交給 Provider
,讓 Component 在測試時,也有 Store 管理提供資料,完成後測試案例會變成這樣:
接著再執行測試,便能看見結果亮起綠燈 PASS:
萬歲!那接下來的「按下按鈕後會送出請求,取得伺服器的資料」需要拆分為以下幾個步驟:
大概就是這樣子,然後這裡其實滿呼應 Day07 | 從 Hooks 開始的 Component 新生活 這篇所說,因為走在尖端上,所以會遇到一些痛點,這個部分筆者在處理 useDispatch
的時候稍微卡了一下,但最後還是順利解決了,那今天會處理的部份會集中在 Component 身上,也就是第一和四步驟,Redux 及 Redux-Saga 就留到明天。
當然!如果以下範例是筆者查找資料思考過後的做法,如果大家有其他最佳實踐都可以留言告訴我!感激不盡!
第一步驟的難點大概是要替 useDispatch 以及他回傳的 dispatch
做 mock,因此先用 Spy 去 Mock react-redux 的 useDispatch
,但是 Spy 得依照整個 Model 取出 Method 才有辦法製造,因此先將 react-redux 內的所有方法統一取出命名,像這樣子:
import * as ReactRedux from 'react-redux';
但這麼做會導致 Content_Check_Render 這個測試案例有問題,因為 Provider
被放到 ReactRedux
裡了,為了防止出錯可以先將 Provider 取出:
const { Provider } = ReactRedux;
前置準備結束後就能開始做 Spy 了:
const mockUseDispatch = jest.spyOn(ReactRedux, 'useDispatch');
再來它會回傳一個 dispatch
,那才是按下按鈕後真正會送入 Action 執行的方法,這裡先用 jest.fn()
,做一個小的 Mock 用來取代 dispatch
:
const mockDispatch = jest.fn();
接下來用 mockReturnValue
替 mockUseDispatch
設置回傳 mockDispatch
:
mockUseDispatch.mockReturnValue(mockDispatch);
替 useDispatch
打造完 Mock 後,可以接著使用上一個測試案例學到的,為 Component 創建 Redux 環境,測試案例會是這樣子:
而在 Content_Click_ExecuteDispatch 要驗證的事情就是,按下按鈕時dispatch
執行的是不是我們預期的 Action,這個部分可以從 mockDispatch.mock.calls
,中去確認它有沒有執行,以及執行時收到的參數為何:
// 取得按鈕並按下
const fetchContentDataBtn = getByTestId('fetchContentDataBtn');
fireEvent.click(fetchContentDataBtn);
// 斷言 mockDispatch 執行時的第一個參數為何
expect(mockDispatch.mock.calls[0][0])
.toEqual({ type: 'FETCH_DATA_BEGIN' });
下完斷言後就可以執行測試,結果應該如下:
這裡被測出有個地方出錯了,提示中說明 mockDispatch
接收到的參數還要有的 payload
,發現到這點後,我也到了 Content
中再確認按下按鈕時有沒有帶任何參數,結果也是沒有的,因此就可以說這個 Action 內的 payload
是多餘的贅 Code,也能開啟 src/action/todolist.js 把 fetchDataBegin
內的 payload
刪掉,因為它不需要任何參數,實務上也沒有給它參數:
export const fetchDataBegin = () => ({
type: FETCH_DATA_BEGIN,
});
修正完因為測試發現的贅 Code 後就能通過了:
但這時候可以發現在每個測試案例都要重新 Render Component 實在是太多餘了,且要是臨時替 Component 增加一個 Props,那需要改動的地方就很多,這麼一來會喪失對測試案例的可維護性,這個情況可以製作一個函式,他會負責回傳 Render 後的 Component,例如:
這麼一來 Content
如果修改時,就能只維護一個地方就行了,以 Content_Check_Render 為例子測試案例就這麼修正,看起來也簡潔許多:
再來是第四點,要確認 Content
內顯示的是否為正確的資訊,也就是 State 中的 data
,但目前為止我們在 Reducer 中的預設 data
是空物件 {}
,這部分要在測試時替它做一個假的 State,並在 createStore
時和 Reducer 一起創建 Store:
至於 useSelector
呢?其實不需要替它做 Mock,因為如果 contentData
內能夠正確顯示出 testInitState
的值的話,就代表 useSelector
有正確執行了,就像是上方我只替 mockDispatch
做測試,卻沒有斷言 mockUseDispatch
有沒有執行過一樣。
最後下完斷言後,測試案例會變成:
經過測試後,結果也是正常的:
到此 Component 的測試就告一段落了,不過還是得再修正一下 generateComponent
,讓它可以傳入 initState
,在 Store 中建立測試用的 State:
而 Content_Render_ContentData 改成這樣子:
這麼一來,即使是不同測試案例需要不同的 State 建立 Store就不用每次都另外寫 Render 的設定了!
本文的範例程式碼會提供在 GitHub 上,歡迎各位參考:)
天真的我曾經以為關於 Redux 的測試講解起來應該容易多了,但沒想到 Hooks 版的 react-redux 會花我那麼多時間,導致要再將 Reducer 和 Saga 的部分拆開到下個章節。
如果文章中有任何問題,或是不理解的地方,都可以留言告訴我!謝謝大家!
我在測試Content_Render_ContentData的時候一直出錯,
後來發現是因為我自己有使用combineReducers,
加上combineReducers又發現無法帶入initState,
上網查了一下:
https://redux.js.org/recipes/structuring-reducers/initializing-state
最後是用這個方式帶入initState,然後就測試成功了。
希望之後有用combineReducers的朋友們不會花太多時間卡在這裡。
我卡了好久QQ
補充 新版的ts可能會造成以下錯誤(react-redux的ts)
TypeError: Cannot redefine property: useDispatch at Function.defineProperty ()
可以使用這個方式解決
jest.mock('react-redux', () => ({
__esModule: true,
// @ts-ignore
...jest.requireActual('react-redux'),
}));
我是也是遇到同樣問題,搞超久Q
參考同一個網址,最後改成這樣,給大家參考
const mockDispatch = jest.fn()
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}))
describe('Content', () => {
test('Content_Click_ExecuteDispatch', () => {
const { getByTestId } = generateComponent(<Content />)
const fetchContentDataBtn = getByTestId('fetchContentDataBtn')
fireEvent.click(fetchContentDataBtn)
expect(mockDispatch).toHaveBeenCalledWith({ type: 'FETCH_DATA_BEGIN' })
})
})